Skip to content

[LFXV2-2645] feat(users): scrub username from committee data on user deletion - #133

Merged
andrest50 merged 16 commits into
mainfrom
fix/LFXV2-2645-scrub-username-on-user-delete
Jul 28, 2026
Merged

[LFXV2-2645] feat(users): scrub username from committee data on user deletion#133
andrest50 merged 16 commits into
mainfrom
fix/LFXV2-2645-scrub-username-on-user-delete

Conversation

@andrest50

@andrest50 andrest50 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Extends handleMergedUserDelete() to publish a lfx.v1-sync-helper.user.deleted NATS event carrying the deleted user's username and primary email
  • The committee service subscribes to this event and performs the username scrub from committee members and settings writers/auditors (see lfx-v2-committee-service#161)
  • Removes the query-service HTTP client, QUERY_SERVICE_URL config, and all direct committee scrub logic from the sync-helper — that responsibility now lives entirely in the committee service
  • Handler is best-effort: publish failures are logged and do not block the delete ACK

Ticket

LFXV2-2645

Changes

Modified files

  • cmd/lfx-v1-sync-helper/handlers_users.go — extends handleMergedUserDelete to publish a user-deleted NATS event; adds publishUserDeletedEvent, getPrimaryEmailForUserFn, and publishUserDeletedEventFn injectable vars
  • cmd/lfx-v1-sync-helper/handlers_users_test.goTestHandleMergedUserDeleteScrub covering event publish, no-username, email-lookup-failure, and hard-KV-delete cases
  • go.mod / go.sum — bumps golang.org/x/text → v0.40.0 and golang.org/x/crypto → v0.54.0 to resolve CVE GO-2026-5970 and GO-2026-5932

Deleted files

  • cmd/lfx-v1-sync-helper/client_query.go — query-service HTTP client (no longer needed)

Notes

  • The username scrub is best-effort: if the committee service is temporarily unavailable when the event is published, the event is lost (core NATS, not JetStream). A stale username in committee display data is recoverable manually and does not affect auth or data integrity. JetStream durability can be added in a follow-up if needed.
  • Hard KV deletes (v1Data == nil) cannot be scrubbed — the payload is gone so the username is unrecoverable. This path logs a warning. Hard deletes are abnormal and should not occur in normal operation.

…V2-2645)

When a merged_user is soft-deleted in the v1-objects KV bucket,
handleMergedUserDelete now clears the deleted user's username from:

- committee_member records (via query-service tag lookup + UpdateCommitteeMember)
- committee_settings writers and auditors entries (via query-service tag
  lookup + UpdateCommitteeSettings); entries are retained, only username
  is cleared

A new query-service HTTP client (client_query.go) issues
GET /query/resources?v=1&type={type}&tags={tagKey}:{username} to discover
affected resources. QUERY_SERVICE_URL configures the endpoint (optional;
scrub is skipped with a warning when absent).

The handler is idempotent: records already cleared or reassigned to a
different user are skipped. The existing v1 username-index cleanup is
unchanged.

Unit tests cover member scrub (7 cases), settings scrub (7 cases), and
the handleMergedUserDelete wiring (3 cases).

Generated with [Claude Code](https://claude.ai/code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Copilot AI review requested due to automatic review settings July 14, 2026 18:34
@andrest50
andrest50 requested review from a team and emsearcy as code owners July 14, 2026 18:34
…test

The field was populated in one test case but never read in the assertion
body — the check hardcoded `!= ""` directly. Removing it avoids misleading
struct fields that appear to carry meaning but have no effect.

Generated with [Claude Code](https://claude.ai/claude-code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds committee username cleanup when deleting merged users.

Changes:

  • Adds query-service configuration and client.
  • Scrubs committee members and settings with reuse guards.
  • Adds unit tests for cleanup behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
client_query.go Adds query-service resource lookup.
config.go Adds query-service URL configuration.
client_committees.go Adds committee fetch/update helpers.
handlers_users.go Implements username scrubbing.
handlers_users_test.go Tests deletion and scrub flows.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/config.go Outdated
Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users_test.go Outdated
Copilot AI review requested due to automatic review settings July 14, 2026 18:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Generated with [Claude Code](https://claude.ai/code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Copilot AI review requested due to automatic review settings July 17, 2026 20:28
@andrest50
andrest50 requested a review from a team as a code owner July 17, 2026 20:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/client_query.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/config.go Outdated
…lete (LFXV2-2645)

Remove the query-service dependency introduced for username scrubbing. Instead,
publish a lfx.v1-sync-helper.user.deleted NATS event carrying the deleted user's
username and primary email. The committee service subscribes to this event and
performs the scrub using its own KV secondary index, keeping the sync-helper
free of any query-service coupling.

Generated with [Claude Code](https://claude.ai/code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Copilot AI review requested due to automatic review settings July 27, 2026 16:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

cmd/lfx-v1-sync-helper/handlers_users.go:153

  • The scrub is skipped in the normal deletion order documented immediately above: handleAlternateEmailDelete removes each email SFID from this user's mapping (handlers_users.go:301-303), while getPrimaryEmailForUser requires that mapping and returns an error when it is absent or empty. If alternate-email rows are deleted before the merged-user row, no event is published and the committee username remains. Source the guard email from data that survives this ordering, or emit/capture the scrub event before the email mapping is removed.
	if username != "" {
		email, emailErr := getPrimaryEmailForUser(ctx, userSfid)
		if emailErr != nil {
			logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid).
				WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped")

cmd/lfx-v1-sync-helper/handlers_users.go:184

  • A core NATS Publish is not a durable handoff: success only means the message was queued on this client. If the committee-service subscriber is unavailable, or this process exits before the socket flushes, the event is lost; the delete handler still returns false, so the source JetStream message is ACKed and never retried. Use a persisted/acknowledged delivery mechanism and propagate publication failure so the source deletion can be retried.
	if err := natsConn.Publish(v1SyncHelperUserDeletedSubject, payload); err != nil {
		logger.With(errKey, err, "key", key).
			ErrorContext(ctx, "failed to publish user-deleted event; committee username scrub skipped")
		return

Comment thread cmd/lfx-v1-sync-helper/handlers_users_test.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go
- golang.org/x/text v0.37.0 → v0.40.0 (fixes GO-2026-5970, High)
- golang.org/x/crypto v0.52.0 → v0.54.0 (fixes GO-2026-5932)
- golang.org/x/sys v0.45.0 → v0.47.0 (transitive)

Generated with [Claude Code](https://claude.ai/code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Copilot AI review requested due to automatic review settings July 27, 2026 16:58
Comment thread go.mod Fixed
Comment thread go.mod Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

cmd/lfx-v1-sync-helper/handlers_users.go:153

  • This lookup depends on the alternate-email mapping and live alternate-email records, but handleAlternateEmailDelete removes each SFID from that mapping before returning (lines 301-315), and getV1ObjectData treats soft-deleted rows as absent (lfx_v1_client.go:1229-1236). The preceding comment notes those rows are normally deleted before or alongside the user, so the normal ordering leaves no primary email, skips this event, and never scrubs committee data. Preserve the primary email independently until the merged-user delete is handled (or resolve it from another durable source) so the event can always be emitted.
	if username != "" {
		email, emailErr := getPrimaryEmailForUser(ctx, userSfid)
		if emailErr != nil {
			logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid).
				WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped")

Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Introduce getPrimaryEmailForUserFn and publishUserDeletedEventFn
package-level vars so tests can stub them without a live NATS connection,
matching the existing pattern for deleteIndexKeyFn and lookupMergedUserFn.

Replace the dangling // TestHandleMergedUserDeleteScrub comment (which
referenced a non-existent function) with the actual test, covering:
- username present + email resolved → event published
- no username → no publish
- email lookup failure → warn, no publish
- nil v1Data (hard KV delete) → no publish

Generated with [Claude Code](https://claude.ai/code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Copilot AI review requested due to automatic review settings July 27, 2026 17:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.

Comment thread cmd/lfx-v1-sync-helper/handlers_users.go
…leanup

handleMergedUserDelete calls getPrimaryEmailForUser to resolve the user's
email for the committee scrub event. That lookup reads the alternate-emails
mapping array, which handleAlternateEmailDelete clears before the merged-user
row is deleted — causing the email lookup to always fail and the scrub event
to never be published.

Fix: when handleAlternateEmailUpdate processes a primary email record
(primary_email__c=true), write the address to a dedicated KV cache key
(v1-user.primary-email.<sfid>). getCachedPrimaryEmailForUser checks this
key first and falls back to getPrimaryEmailForUser; handleMergedUserDelete
uses getCachedPrimaryEmailForUser via the injectable getPrimaryEmailForUserFn
var and deletes the cache key after publishing the event.

Generated with [Claude Code](https://claude.ai/code)

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Copilot AI review requested due to automatic review settings July 27, 2026 17:41
Include deleted account email in the scrub event payload for downstream
reuse guards, reject whitespace-only usernames, and revert x/* bumps that
failed license compliance.

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings July 27, 2026 23:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

cmd/lfx-v1-sync-helper/handlers_users.go:163

  • getPrimaryEmailForUser reads the alternate-email mapping, but handleAlternateEmailDelete normally removes those entries before the merged-user deletion. This therefore commonly returns an error; the error is discarded and an event without email is still published. The linked committee consumer only applies its LFID-reuse guard when email is non-empty, so a delayed event can clear a reassigned user's committee username. Persist the primary email before alternate-email cleanup (as the earlier cache implementation did) and publish only with that guard value; the added test should also exercise lookup failure instead of always returning success.
		email, _ := getPrimaryEmailForUserFn(ctx, userSfid)
		publishUserDeletedEventFn(ctx, key, normalizedUsername, email)

cmd/lfx-v1-sync-helper/handlers_users.go:61

  • The PR description still claims go.mod/go.sum upgrade x/text to v0.40.0 and x/crypto to v0.54.0, but the current PR changes only these two handler files and the repository remains on v0.37.0/v0.52.0. Thus the advertised dependency/CVE remediation is not part of this change. Update the PR description to note that the bumps were reverted, or restore a compliant remediation in a separate dependency change.
	publishUserDeletedEventFn = publishUserDeletedEvent
	getPrimaryEmailForUserFn  = getPrimaryEmailForUser

Upgrade golang.org/x/text to v0.39.0 (GO-2026-5970) and refresh pip/uv
lock constraints to clear MegaLinter grype failures.

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings July 27, 2026 23:45
Comment thread go.mod

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

cmd/lfx-v1-sync-helper/handlers_users.go:163

  • Do not publish a username-only deletion when this lookup fails. getPrimaryEmailForUser reads the alternate-email mapping and live rows, but handleAlternateEmailDelete normally removes that mapping before the merged-user delete arrives. The linked committee consumer only applies its LFID-reuse guard when email is non-empty, so this fallback can scrub a newly reassigned user's committee identity/access. Persist the primary email before alternate-email cleanup (as the prior fix described), use that durable value here, and cover lookup failure in the test.
		email, _ := getPrimaryEmailForUserFn(ctx, userSfid)
		publishUserDeletedEventFn(ctx, key, normalizedUsername, email)

go.mod:25

  • The dependency changes do not match the PR's stated security update: this sets x/text to v0.39.0 rather than v0.40.0, while x/crypto remains at v0.52.0 instead of the described v0.54.0. Regenerate go.mod/go.sum with the intended versions, or correct the PR description and vulnerability claims if these versions are deliberate.
	golang.org/x/text v0.39.0

@dealako dealako left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @andrest50 👋 — thanks for the quick turnaround on my last pass. This is a follow-up review after the three new commits (e92d7d1, 688db4a, 02e5766) that landed and dismissed my prior "Approved with comments."

👏 Nice work

  • Question #1 (raw-vs-normalized username contract) — resolved. e92d7d1 now normalizes the published username via normalizeKVSegment (TrimSpace → ToLower → NFC), so decomposed-Unicode / case / whitespace variants can't silently miss the downstream scrub. Good call routing the shared pipeline through one helper.
  • Prior Nit (untested publish wrapper) — resolved. TestPublishUserDeletedEvent + the injectable natsPublishBytesFn seam now cover the publish path, including the error-swallowed case. Clean use of the existing Fn-var pattern.
  • Question #2 (username reuse) — partially mitigated by carrying the primary email in the payload so downstream scrubbers can distinguish LFID reuse. Good instinct — see the caveat below on whether it actually populates.
  • All CI green (MegaLinter, CodeQL, License, DCO), targeted tests pass locally.

Revision tracking

Prior item Status
Q1 — raw vs normalized username contract ✅ Resolved (e92d7d1)
Nit — publish wrapper untested ✅ Resolved (TestPublishUserDeletedEvent)
Nit — inaccurate x/crypto bump rationale ✅ Moot — net change vs main is a clean forward bump (x/text 0.37.0 → 0.39.0; x/crypto/x/sys unchanged). No CVE regression.
Q2 — best-effort durability + username reuse ⚠️ Partially addressed — email added for reuse detection, but see 🔴 #1 (it's usually empty); durability tradeoff still documented-as-accepted

New findings this round

🔴 Blocking

1. Primary-email enrichment is usually empty in the common delete path — undermining the reuse-detection it was added for (handlers_users.go:161-163)
getPrimaryEmailForUserFn(ctx, userSfid) reconstructs the primary email by reading the v1-merged-user.alternate-emails.<sfid> mapping and the per-email rows. But your own TODO at lines 155-159 states those alternate-email rows are "deleted before or alongside the user row." So at user-delete time this lookup will frequently hit ErrKeyNotFoundemail == "". Since 688db4a added email specifically so downstream can distinguish LFID reuse (the exact risk from my Q2), an empty email in the common case means a delayed event after LFID reassignment could still scrub a new owner. Please either capture the primary email earlier (e.g. from the user payload / a value written on update, which is what 0caa31c attempted before it was reverted), or drop the email field if it can't be reliably populated — shipping a field that's empty in the common path is worse than not having it. A design confirmation would close this out.

🟡 Minor

2. Doc comment detached from toKVKey, now misdescribes normalizeKVSegment (handlers_users.go:64-70)
e92d7d1 inserted normalizeKVSegment directly beneath the existing toKVKey godoc block, so that comment ("...encodes it as a URL-safe base64 key segment ... RawURLEncoding keeps keys opaque and short") is now the godoc for normalizeKVSegment, which does none of the base64 encoding. Meanwhile toKVKey is left undocumented. Give normalizeKVSegment its own one-liner (TrimSpace→ToLower→NFC) and move the base64/RawURLEncoding sentence back down onto toKVKey.

3. getPrimaryEmailForUser error silently discarded (handlers_users.go:162)
email, _ := getPrimaryEmailForUserFn(...) drops the error, so an empty email is indistinguishable from a lookup failure in production logs. Combined with #1, a one-line DebugContext on the error would make the (expected) empty-email case observable.

4. Test gap: empty/failed-email path through handleMergedUserDelete not covered (handlers_users_test.go)
TestHandleMergedUserDeleteScrub always stubs getPrimaryEmailForUserFn"deleted@example.com", nil. Given #1 makes empty-email the expected production path, add a case where the lookup returns "", err and assert wantPublished: true, wantEmail: "". (TestPublishUserDeletedEvent exercises "" but bypasses the handler.)

5. Primary email logged at DEBUG, now reachable from the delete path (lfx_v1_client.go:295)
getPrimaryEmailForUser logs the full fallbackEmail. Pre-existing and DEBUG-gated, but the new delete flow widens its exposure — with DEBUG=true every deleted user's primary email now hits logs. Consider masking. (Good that publishUserDeletedEvent itself keeps email out of its INFO line.)

⚪ Nit

6. Redundant double-normalization (handlers_users.go:145 & 161) — usernameToKVKey(username) and normalizeKVSegment(username) both run the NFC/lower/trim pipeline on the same input. Compute norm := normalizeKVSegment(username) once and derive the index key from it. Not worth churn on its own.

AI reconciliation

Copilot has 0 active inline comments — its earlier notes were against removed code (primary-email cache / query-service client) and are stale. No new bot findings to reconcile.

Issue count (carried + new)

  • 🔴 Blocking: 1 — email enrichment empty in common delete path
  • 🟡 Minor: 4 — detached godoc; swallowed error; empty-email test gap; DEBUG email log
  • Nit: 1 — redundant double-normalization

🔴 Final decision: Needs changes before approval

No mechanical bugs — tests are green and the username scrub itself is sound. The blocker is a design/correctness question: the email field added this round is likely empty in the common case, so it doesn't yet deliver the reuse-detection it was meant to. Confirm the ordering assumption and either populate email reliably or drop it, then knock out the doc/test/logging minors. Happy to re-approve quickly once #1 is settled.

Comment thread cmd/lfx-v1-sync-helper/handlers_users.go
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Reintroduce v1-user.primary-email.* cache written on primary alternate-email
updates so handleMergedUserDelete can populate email after alternate-email
rows are cleaned up; add debug logging on lookup miss and split normalizeKVSegment/toKVKey godoc.

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings July 28, 2026 15:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (5)

cmd/lfx-v1-sync-helper/handlers_users.go:325

  • This cache is populated only when a primary-email row is processed after this deployment. The existing durable KV consumer will not replay already-ACKed rows, and --rebuild-user-secondary-indexes currently writes only v1-user.email.*, so existing users generally have no primary-email cache. With the documented deletion ordering, the live fallback is then gone and the event omits email; the linked committee consumer disables its reuse guard when email is empty and scrubs solely by username, which can clear a reassigned LFID. Backfill these keys and sequence that backfill before enabling the publisher, or make email-less events safe in the consumer.
	// Primary emails are not linked as Auth0 identities (they are the Auth0 user's own email).
	// Cache the address in mappings so handleMergedUserDelete can supply it to downstream
	// scrubbers even after the alternate-email rows have been cleaned up ahead of the
	// merged-user row.
	if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary {

cmd/lfx-v1-sync-helper/handlers_users.go:331

  • A transient failure here is logged and then the handler returns false, ACKing the only row event that would populate this cache. Because the primary row is normally removed before the merged-user deletion, the later fallback cannot recover the address, permanently dropping the email reuse guard. Request redelivery when this cache write fails, as the preceding mapping write already does.
			if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil {
				logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid).
					WarnContext(ctx, "failed to cache primary email for user deletion scrub")
			}

cmd/lfx-v1-sync-helper/handlers_users.go:176

  • The producer applies NFC here, but the linked committee-service consumer builds its username index with only TrimSpace + ToLower and compares settings with EqualFold. For historical decomposed Unicode usernames (which this repository explicitly supports), the event contains the composed form, so the consumer hashes a different value and canonical-equivalent settings also fail to match; those usernames are never scrubbed. Define one normalization contract and apply NFC in both producer and consumer index/comparison paths.
	if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" {

go.mod:25

  • The PR description claims x/text is bumped to v0.40.0 and x/crypto to v0.54.0 to resolve GO-2026-5970/GO-2026-5932, but this diff sets x/text v0.39.0 and leaves indirect x/crypto at v0.52.0. The official advisory says v0.39.0 does fix GO-2026-5970, while GO-2026-5932 has no fixed x/crypto/openpgp version, so the stated security remediation cannot match these changes. Reconcile the PR’s security claim and dependency plan.
	golang.org/x/text v0.39.0

cmd/lfx-v1-sync-helper/handlers_users.go:190

  • The primary-email cache is deleted only inside the non-empty-username branch. A soft-deleted user with a missing/blank username (explicitly supported by the new tests), and every hard delete that returns earlier, leaves the newly persisted email in v1-mappings indefinitely even though userSfid is sufficient to address the cache key. Move cache cleanup so every deletion path attempts it, independently of whether an event can be published.
		// Best-effort cleanup of the cached primary-email key.
		if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailByUserSfid+userSfid); err != nil {
			logger.With(errKey, err, "key", key, "user_sfid", userSfid).
				WarnContext(ctx, "failed to delete cached primary email after user-deleted event")
		}

@andrest50
andrest50 requested a review from dealako July 28, 2026 16:02

@dealako dealako left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @andrest50 👋 — thanks for the thorough turnaround on this. This follow-up review covers the single new commit 3047696 ("restore primary-email cache for user.deleted event"), and it cleanly closes out my blocker from the last round.

👏 Nice work

  • 🔴 Blocker resolved — email enrichment now actually populates. getCachedPrimaryEmailForUser reads a v1-user.primary-email.<sfid> cache written at handleAlternateEmailUpdate time (which survives the alternate-email-row cleanup that races ahead of the user delete), with a live-lookup fallback. This is exactly the ordering fix that was needed — and you wired in best-effort cache cleanup on delete plus a doc comment explaining the rationale.
  • Godoc fixednormalizeKVSegment (TrimSpace→ToLower→NFC) and toKVKey (RawURLEncoding) now each carry their own accurate comment.
  • Error handling fixed — the lookup error and the empty-email case are now logged at Debug before publishing without email.
  • Strong test additionsTestGetCachedPrimaryEmailForUser (hit + miss→live) and the extended scrub test asserting the email-error path and cache-key cleanup. Fixtures are synthetic (cached@/live@/deleted@example.com). Tests green, go vet clean.

Revision tracking (prior round)

Prior item Status
🔴 Email enrichment empty in common delete path ✅ Resolved (3047696 — cache restore)
🟡 Detached godoc on normalizeKVSegment/toKVKey ✅ Resolved
🟡 getPrimaryEmailForUser error silently discarded ✅ Resolved (Debug logs on error + empty)
🟡 Test gap: empty/failed-email path ✅ Resolved (email lookup error → still publish without email case)
🟡 Primary email logged at DEBUG (lfx_v1_client.go) ⚠️ Still open — pre-existing, now the fallback path; mask when convenient
⚪ Redundant double-normalization ⚪ Not addressed (nit, carried)

New findings this round

Both my parallel security + code-quality passes agree there are no blocking issues — the mechanics are correct and well-tested. Remaining items cluster around the new cache's lifecycle:

🟡 Minor

  1. Stale cached email on demote / primary-row delete (handlers_users.go:325, :444) — the cache is written only when primary_email__c=true and cleaned only on full user delete. If a user's primary email is demoted (true→false) or the primary alt-email row is deleted (which handleAlternateEmailDelete explicitly skips at line 444), the cache is neither updated nor removed. Since getCachedPrimaryEmailForUser is cache-first, a later delete would emit a stale address, and the PII copy is retained indefinitely for a demoted-but-never-deleted user. For a PR whose whole purpose is scrubbing PII, that retention gap is worth closing — add a cache delete before the line-444 early return (and/or on demote). If you'd rather defer, a one-line comment at the write site noting the demote/delete path is intentionally uncovered + a follow-up ticket would satisfy me.
  2. Cache key orphaned when a deleted user has an empty username (handlers_users.go:176-188) — the deleteIndexKeyFn(...primary-email...) cleanup sits inside the if normalizedUsername != "" guard, so a user with a cached primary email but blank username never gets the cache key removed. Consider hoisting the cache cleanup outside the username guard.
  3. Cache write path untested (handlers_users.go:327) — reads go through the swappable readMappingsKVValueFn (tested), but the mappingsKV.Put write in handleAlternateEmailUpdate has no coverage ("primary email arrives → cache key written"). Asymmetric testability.
  4. Missing empty/whitespace cache-value test branchgetCachedPrimaryEmailForUser correctly TrimSpaces and falls through to live lookup on a blank cached value, but no test exercises that branch.

⚪ Nit

  1. Const naming (handlers_users.go:26) — kvKeyPrimaryEmailByUserSfid drops the Prefix suffix its three siblings use (kvKeyUsernamePrefix, etc.); rename to kvKeyPrimaryEmailPrefix for consistency.
  2. Repeated kvKeyPrimaryEmailByUserSfid + userSfid across write/read/delete (3×) — a tiny primaryEmailCacheKey(sfid) helper would centralize it. Optional.

AI reconciliation

Copilot has no new active inline comments this round. No external bot findings to reconcile.

Issue count

  • 🔴 Blocking: 0
  • 🟡 Minor: 4 — stale/retained cache on demote/primary-delete; orphaned cache on empty-username delete; untested cache write; missing empty-value test branch (+1 carried: DEBUG email log)
  • Nit: 3 — const naming; repeated key expression; carried double-normalization

🔴 Final decision: Needs changes before approval

This is very close — the blocker is gone and the feature is correct and well-tested. I'm holding on a formal approval only for the cache-lifecycle cluster (item #1 in particular, since stale/retained PII is thematically at odds with a scrub feature). Address #1 (fix or consciously defer with a follow-up ticket) and, ideally, the quick #2/#3, and I'll re-approve promptly. #4#6 I'm happy to take as follow-ups.

Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Hoist cache delete outside the username guard, clear cache on primary-email
delete/demote, and rename kvKeyPrimaryEmailPrefix for naming consistency.

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings July 28, 2026 17:54
Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (5)

cmd/lfx-v1-sync-helper/handlers_users.go:468

  • The normal user-delete ordering removes alternate-email rows before the merged-user row, but this branch deletes the primary-email cache at that first step. Because the mapping was already pruned above, getCachedPrimaryEmailForUser later has neither the cache nor the live primary row, so the event is emitted without the email the reuse guard needs. Preserve or replace this value until the merged-user deletion has published.
		clearPrimaryEmailCacheIfMatched(ctx, key, userSfid, emailAddr)

cmd/lfx-v1-sync-helper/handlers_users.go:185

  • Publishing after an email lookup failure disables the linked committee consumer's reuse guard: PR #161 only compares stored email when event.Email is non-empty. A delayed deletion can therefore clear the username of a new owner with the same LFID. Require a durable identity before publishing, or make all consumers reject email-less events.
		publishUserDeletedEventFn(ctx, key, normalizedUsername, email)

cmd/lfx-v1-sync-helper/handlers_users.go:352

  • This new cache is populated only when a primary-email update is processed after deployment, and a failed Put is ACKed instead of retried. The existing durable consumer does not replay already-ACKed records, so unchanged users—and users hit by one transient write failure—will have no cache when later deleted. Backfill existing primary emails and request redelivery when this required cache write fails.
			cacheKey := kvKeyPrimaryEmailPrefix + leadorcontactid
			if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil {
				logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid).
					WarnContext(ctx, "failed to cache primary email for user deletion scrub")
			}

cmd/lfx-v1-sync-helper/handlers_users.go:176

  • The producer normalizes usernames to NFC, but the linked committee consumer's BuildUsernameIndexKey and usernameMatches use only trim/lowercase. For decomposed Unicode LFIDs (explicitly supported by this repository), the event hashes and compares differently, so neither members nor settings are scrubbed. Use the same NFC normalization in every producer and consumer.
	if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" {

go.mod:25

  • The PR description says this update sets x/text to v0.40.0 and x/crypto to v0.54.0, but the diff sets x/text to v0.39.0 and leaves x/crypto at v0.52.0. Update the dependency/security claims to match the committed modules, or commit the intended versions.
	golang.org/x/text v0.39.0

@dealako dealako left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @andrest50 👋 — thanks for hardening the cache lifecycle so quickly. Both of my prior minors are cleanly resolved and the design is right. We're down to the last mile: one formatting fix and one test that actually asserts the new behavior.

👏 Nice work

  • Stale/retained cache on demote & primary-row delete — resolved. The new clearPrimaryEmailCacheIfMatched helper is a nice design: it only deletes the cache when the stored value still matches the affected address (EqualFold + TrimSpace), so a legitimate newer primary (A→B) is never wiped. Wired into both the demotion branch (handleAlternateEmailUpdate, non-primary) and the primary-row-delete branch (handleAlternateEmailDelete). Both my parallel passes confirmed the ordering is correct and the value-match protects against false clears.
  • Blank-username orphan — resolved. Cache cleanup hoisted outside the if normalizedUsername != "" guard, and you added whitespace-only / no username test cases that assert the cache key is still deleted.
  • Const rename — done (kvKeyPrimaryEmailPrefix, matches siblings).

Revision tracking (prior round)

Prior item Status
🟡 Stale cache on demote / primary-row delete ✅ Resolved (09bf1c2clearPrimaryEmailCacheIfMatched)
🟡 Cache cleanup orphaned on blank-username delete ✅ Resolved (hoisted + tested)
⚪ Const naming ...ByUserSfid...Prefix ✅ Resolved (but see 🟡 #1 below — introduced a gofmt miss)
🟡 Cache write path untested ❌ Still open — no TestHandleAlternateEmailUpdate; write + demote-clear uncovered
🟡 Empty/whitespace cache-value test branch ❌ Still open — clearPrimaryEmailCacheIfMatched not directly tested
⚪ Repeated key expression / DEBUG email log (lfx_v1_client.go) ⚪ Carried nits

New findings this round

🟡 Minor

  1. gofmt not clean — const block misaligned by the rename (handlers_users.go:26). The rename shortened the longest identifier, so the whole const block needs to collapse one column, but the padding wasn't updated — gofmt -l flags the file and make check will reformat it. (Interestingly CI MegaLinter is green, so it won't hard-block the gate, but it violates the repo's gofmt standard.) One-liner fix:
    gofmt -w cmd/lfx-v1-sync-helper/handlers_users.go
    
  2. The new clear-on-match logic ships with no test that asserts a deletion (handlers_users.go:214, :357, :468). This is the substantive one:
    • There's no TestHandleAlternateEmailUpdate at all, so the demotion clear path (:357) — the behavior this round adds — has zero coverage.
    • In TestHandleAlternateEmailDelete, the new readMappingsKVValueFn stub always returns errors.New("cache miss"), so the primary-delete clear (:468) hits the read-error early-return and no-ops — nothing asserts a cache delete.
    • clearPrimaryEmailCacheIfMatched isn't unit-tested for its four documented branches (match→delete, no-match→skip, empty-email→skip, read-error→skip).
      A small direct table test on clearPrimaryEmailCacheIfMatched (asserting deleteIndexKeyFn is/ isn't called per branch) would lock in the logic and is the cleanest way to close this.

⚪ Nit

  1. Read-error silently leaves stale cache (handlers_users.go:217) — reasonable (you can't value-match without the value, and blind-deleting risks dropping a different valid primary), but a one-line comment noting the trade-off, or a DebugContext, would help. A transient read error during a genuine demotion leaves the cache stale until the user-delete path cleans it.
  2. Hard-delete of a primary row doesn't clear the cache (handlers_users.gohandleAlternateEmailDelete returns early on v1Data == nil before the clear). Edge case only (soft deletes always carry a payload); the unconditional user-delete cleanup still catches it.
  3. Redundant userSfid == "" guard in clearPrimaryEmailCacheIfMatched — both call sites already guarantee non-empty. Harmless defense-in-depth.

AI reconciliation

No new external bot inline comments this round. Author replies confirm the fixes map to 09bf1c2. Nothing to reconcile.

Issue count

  • 🔴 Blocking: 0
  • 🟡 Minor: 2 — gofmt not clean; clear-on-match logic has no asserting test
  • Nit: 3 — silent read-error; hard-delete edge case; redundant guard (+ carried nits)

🔴 Final decision: Needs changes before approval

Genuinely the last mile — the feature is correct and the cache lifecycle is now sound. Holding formal approval only for the two 🟡 items: run gofmt -w, and add a test that actually asserts clearPrimaryEmailCacheIfMatched deletes on a value match (ideally covering the demotion path too). Do those and I'll approve on the next pass; the nits are take-it-or-leave-it.

Comment thread cmd/lfx-v1-sync-helper/handlers_users.go Outdated
Comment thread cmd/lfx-v1-sync-helper/handlers_users.go
Run gofmt on KV prefix const block, add TestClearPrimaryEmailCacheIfMatched,
drop raw LFID from publish INFO log, and clear primary-email cache on hard delete.

Signed-off-by: Andres Tobon <andrest2455@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings July 28, 2026 18:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

cmd/lfx-v1-sync-helper/handlers_users.go:189

  • The linked committee-service consumer only applies the email reuse guard when email is non-empty. Publishing here after a lookup error or empty result therefore creates a username-only event that can clear a reassigned LFID's committee identity and access. Do not publish an unguarded event; skip it until an immutable email is available (while retaining the cache for recovery), or make every consumer reject email-less events.
		publishUserDeletedEventFn(ctx, key, normalizedUsername, email)

cmd/lfx-v1-sync-helper/handlers_users.go:472

  • This deletes the cache on the exact ordering it was introduced to handle: primary alternate-email rows are normally deleted before the merged-user row. The mapping array has already been cleaned above, so the later merged-user handler cannot recover the email; existing users also will not have received a cache write from this new version. Persist emailAddr into the primary-email cache from this deletion payload (and retry a failed write) rather than clearing it, then delete it only after the merged-user event is handled.
		clearPrimaryEmailCacheIfMatched(ctx, key, userSfid, emailAddr)

go.mod:25

  • The dependency changes do not match the PR's stated security update: this lands x/text v0.39.0 rather than v0.40.0, while x/crypto remains v0.52.0 rather than v0.54.0. Also, GO-2026-5932 has no fixed x/crypto version and applies to the deprecated openpgp packages, which this repository does not import. Align go.mod/go.sum with the intended update and correct the security rationale so the PR does not claim remediation that is absent or inapplicable.
	golang.org/x/text v0.39.0

@andrest50
andrest50 requested a review from dealako July 28, 2026 18:49

@dealako dealako left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @andrest50 👋 — that's the last mile done. Both items from my previous review are resolved, and you proactively went a step further on PII. Approving. 🎉

👏 Nice work

  • gofmt — fixed. Const block realigned; gofmt -l and go vet are clean, full package test suite green.
  • Clear-on-match now has real coverage. TestClearPrimaryEmailCacheIfMatched is a proper table test: match→delete, case-insensitive match→delete, no-match→skip, empty-email→skip, empty-sfid→skip, read-error→skip — each asserting exactly one delete (of the right key) or zero. This pins down the exact behavior I was worried about last round. With the function directly unit-tested, the thin demotion/delete call-sites no longer need handler-level coverage.
  • Proactive PII hardening (above and beyond):
    • Removed username from the publish-success Info log — the LFID is the very thing being scrubbed, so keeping it out of logs is the right call.
    • Added primary-email cache cleanup to the hard-KV-delete (v1Data == nil) branch (my prior nit), and asserted it in TestHandleMergedUserDeleteScrub — closes the last cache-orphan path.

Revision tracking — all resolved

Prior item Status
🟡 gofmt not clean (const misalignment) ✅ Resolved (f185b27)
🟡 Clear-on-match logic had no asserting test ✅ Resolved (TestClearPrimaryEmailCacheIfMatched)
⚪ Hard-delete of primary row didn't clear cache ✅ Resolved (proactive)
username in publish log ✅ Resolved (proactive)
⚪ Silent read-error / redundant sfid guard ⚪ Left as-is — fine, take-it-or-leave-it

Remaining items (all optional — none blocking)

  • Nit — the clearPrimaryEmailCacheIfMatched Warn reads "failed to delete stale primary email cache," but at the hard-delete/demote call-sites the entry is a matched live cache being cleaned, not necessarily stale. Cosmetic wording only.
  • NitTestClearPrimaryEmailCacheIfMatched could add an empty/whitespace cached value case, but the TrimSpace+EqualFold behavior is already correct.
  • 📌 Follow-up (out of scope for this PR) — the pre-existing Auth0 link/unlink paths (handlers_users.go ~499/503/529/533) still log real email values at Warn/Error. Not touched by this PR, but worth a follow-up ticket to align them with the PII-hardening direction this change establishes.

AI reconciliation

Copilot's latest review carried no actionable inline comments. Nothing to reconcile.

Issue count

  • 🔴 Blocking: 0
  • 🟡 Minor: 0
  • Nit: 2 (both optional) + 1 out-of-scope follow-up

✅ Final decision: Approved with minor comments

Clean, correct, well-tested, and MERGEABLE. Thanks for the thorough iteration through the cache-lifecycle and PII edge cases — nicely done. Ship it once you're happy with the optional nits (or file the Auth0-logging follow-up separately).

@andrest50
andrest50 merged commit bef0d42 into main Jul 28, 2026
10 checks passed
@andrest50
andrest50 deleted the fix/LFXV2-2645-scrub-username-on-user-delete branch July 28, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants